iT邦幫忙

2026 iThome 鐵人賽

DAY 15
1
AI Engineering

從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作系列 第 15 篇

# Day 15:【RAG 時間陷阱】Agent 怎麼知道那是多久以前的事?實作 Time-Aware 動態時間檢索 ## 前言

  • 分享至 

  • xImage
  •  

前言

在實作 RAG 檢索時,我發現了一個非常隱蔽卻致命的「時間陷阱」:

如果我在三天前跟 Agent 說:「我下週二有資工系期末考,最近壓力很大。」
到了今天,當我跟她聊起:「今天考試終於考完了,好輕鬆喔!」

傳統的向量搜尋(Similarity Search)只看「文字語意相近」,結果 ChromaDB 依然會把三天前那條「下週二要考試」的記憶撈出來。這會導致 Agent 冒出一句:「你不是下週二才要考試嗎?現在放鬆什麼?」,直接產生時間錯亂的崩壞感!

為了解決這個問題,今天我們來實作 Time-Aware(時間感知)檢索機制,讓陪伴 Agent 的外掛大腦不只要懂得「語意」,還要懂得「時間的流逝」!

核心觀念:為記憶加上時間戳記 (Timestamp)

要讓 Agent 擁有時間感,關鍵在於兩點:

  1. 寫入記憶時:必須把當下的時間(ISO 格式或 Epoch 時間戳)作為 Metadata 一併存入 ChromaDB。
  2. 檢索記憶時:利用相對時間計算(例如「3 天前」),結合當下的真實時間錨點,將時間資訊動態轉換成自然的 Context 丟給 Gemini 推理。

第一關:升級 rag_memory.py 支援時間戳寫入

打開 rag_memory.py,我們為使用者記憶加上 UTC 時間戳記,並新增支援讀取 Metadata 的查詢函式:

from datetime import datetime, timezone

# --- 軌道 B:使用者記憶相關 API ---
def add_user_memory(memory_id: str, text: str, user_id: str = "default_user"):
    """記錄使用者的偏好、說過的小事(自動附帶 UTC 時間戳)"""
    # 存 UTC 而不是本地時間:時區資訊帶在字串裡,之後換機器或部署到雲端都不會錯亂
    now_iso = datetime.now(timezone.utc).isoformat()

    user_memory_collection.upsert(
        documents=[text],
        metadatas=[{
            "user_id": user_id,
            "created_at": now_iso,
        }],
        ids=[memory_id]
    )

def query_user_memory_with_time(query_text: str, n_results: int = 2) -> list[dict]:
    """搜尋使用者記憶,同時帶回「文本」與「建立時間」。

    純語意搜尋只看文字像不像,撈不出時間感,三天前說「下週二要考試」,
    今天說「終於考完了」還是會把那筆撈出來,莉莉就會冒出
    「你不是下週二才要考嗎」這種時間錯亂的話。
    把 created_at 一起帶回去,讓模型自己判斷那是多久以前的事。

    Returns:
        list[dict]: 每筆包含 {'text': str, 'created_at': str}
                    created_at 是 UTC ISO 字串;
                    加時間戳之前寫入的舊資料沒有這個欄位,會拿到空字串
    """
    results = user_memory_collection.query(
        query_embeddings=_query_ef([query_text]),
        n_results=n_results,
        include=["documents", "metadatas"]   # 要拿 created_at 就得明確要求 metadatas
    )

    if not results['documents'] or not results['documents'][0]:
        return []

    return [
        {"text": doc, "created_at": (meta or {}).get("created_at", "")}
        for doc, meta in zip(results['documents'][0], results['metadatas'][0])
    ]

第二關:rag_builder.py(動態計算時間差距與時間錨點)

有了上面的 rag_memory.py 之後,我寫一個組裝器,負責計算「這條記憶是多久前的」:

from datetime import datetime, timezone
import rag_memory

def _calculate_time_ago(iso_str: str) -> str:
    """計算記憶發生的時間距今多久"""
    if not iso_str:
        return "過去"
    try:
        past_time = datetime.fromisoformat(iso_str)
        now = datetime.now(timezone.utc)
        diff = now - past_time

        if diff.total_seconds() < 0:
            return "剛剛"

        days = diff.days
        hours = diff.seconds // 3600
        minutes = (diff.seconds % 3600) // 60

        if days > 0:
            return f"{days} 天前"
        elif hours > 0:
            return f"{hours} 小時前"
        elif minutes > 0:
            return f"{minutes} 分鐘前"
        else:
            return "剛剛"
    except Exception as e:
        # 不要靜默吞掉:最常見的是 tz-aware 與 naive 時間相減,
        # 只回傳「不久前」的話,你永遠不會知道時間戳其實壞了
        print(f"[rag_builder] 時間解析失敗:{iso_str!r} — {type(e).__name__}: {e}")
        return "不久前"

def build_rag_context(user_input: str) -> str:
    """根據使用者輸入,動態組裝包含時間感知 (Time-Aware) 的 Context Prompt"""
    lores = rag_memory.query_character_lore(user_input, n_results=1)
    memories = rag_memory.query_user_memory_with_time(user_input, n_results=2)

    context_parts = []

    if lores:
        lore_text = "\n".join([f"- {lore}" for lore in lores])
        context_parts.append(f"【檢索到的角色設定 (Lore)】:\n{lore_text}")

    if memories:
        memory_lines = []
        for m in memories:
            time_ago = _calculate_time_ago(m["created_at"])
            memory_lines.append(f"- [{time_ago}] {m['text']}")
        memory_text = "\n".join(memory_lines)
        context_parts.append(f"【關於使用者的歷史記憶(包含時間註記)】:\n{memory_text}")

    if not context_parts:
        return ""

    full_context = "\n\n".join(context_parts)

    # 告訴模型「現在」是什麼時候。
    # 模型只知道訓練資料的年代,實測問它今天幾號,它會答成兩年前的日期。
    # 但使用者說的「下週二」是絕對時間,沒有今天的錨點就無從推算過了沒。
    # 用本地時間而非 UTC,因為使用者講的「下週二」是本地概念。
    now_local = datetime.now().astimezone()
    weekday_zh = "一二三四五六日"[now_local.weekday()]
    now_text = f"{now_local.strftime('%Y-%m-%d %H:%M')}(星期{weekday_zh})"

    return (
        f"--- [RAG 檢索輔助記憶] ---\n"
        f"【現在時間】{now_text}\n\n"
        f"{full_context}\n"
        f"(注意事項:請根據記憶發生的時間差距與邏輯來回應。若記憶為數天前的預定事項,"
        f"請自行推算是否已發生或完成,維持對話自然度。)"
    )

第三步:實際測試!見證時間記憶開花結果

【RAG 實戰成果驗證】

當我刻意詢問:「我下週二的考試是不是快到了?」

  • 背景運作:RAG 檢索出 [3 天前] 使用者下周二要參加資工系的期末考,最近壓力很大。,並且 prompt 中包含了目前的真實時間。

  • 莉莉回應:

    「哈?!你現在是在說夢話嗎?今天都星期四了,下週二不就只剩幾天了嗎?你這傢伙到底有沒有在緊張啊?要是到時候考砸了哭著臉回來,我絕對不會安慰你的喔!聽懂了就快點給我看書去,笨蛋!」

https://ithelp.ithome.com.tw/upload/images/20260929/20183877B3jif9wBwy.png

當我刻意詢問:「我下週二的考試是不是快到了?」

系統展現了非常驚艷的檢索與推理能力:

  1. 自訂 Embedding 消除跨語言誤差:透過寫好的 GeminiEmbeddingFunction 指定 RETRIEVAL_DOCUMENT 與 RETRIEVAL_QUERY 雙向 Task Type,語意比對準確度大幅提升。
  2. Metadata 時間戳:存入 created_at(UTC ISO 格式),確保時區跨平台部署時不會發生混亂。
  3. 相對時間轉換 + 現在時間錨點:賦予 LLM 推理時間差的能力,徹底告別「記憶時間錯亂」與「強行聊舊聞」的尷尬狀況!

上一篇
Day 14:【RAG 實戰篇2】讓記憶發揮作用!將 ChromaDB 動態注入 Gemini 對話閉環
下一篇
Day 16:【狀態防禦篇】解決 API 失敗造成的好感度漏洞!實作「先 Preview、成功才落地」機制
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言